๋ชจ์œผ์žก-๊ธฐํš, ๋””์ž์ธ ์ˆ˜์ •

@choi2021 ยท December 23, 2022 ยท 22 min read

๐Ÿ“‹ ๊ธฐํš ์ˆ˜์ •

๊ธฐ์กด์˜ ๋ชจ์œผ์žก์œผ๋กœ ์•ฝ 6๋ช… ์ •๋„ ์ง€์ธ๋“ค์—๊ฒŒ ๋ณด์—ฌ์ฃผ๊ณ  ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์•˜๋‹ค. ํ”ผ๋“œ๋ฐฑ๋“ค ๋•๋ถ„์— ๋ณด๋‹ค ๊ฐ๊ด€์ ์œผ๋กœ ํ”„๋กœ์ ํŠธ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

๋จผ์ € ๋กœ๊ทธ์ธ์„ ํ•ด์•ผ ์ฑ„์šฉ๊ณต๊ณ ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋‹ค๋Š” ์ ์ด์—ˆ๋‹ค. ๋งจ ์ฒ˜์Œ ๋ณผ ์ˆ˜ ์žˆ๋Š” ํ™”๋ฉด์ด ๋กœ๊ทธ์ธ ํ™”๋ฉด์ด์—ˆ๊ธฐ ๋•Œ๋ฌธ์— ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์ด ์ข‹์ง€ ์•Š๋‹ค๋Š” ํ”ผ๋“œ๋ฐฑ์„ ๋“ค์—ˆ๊ณ , ์ ๊ทน ๊ณต๊ฐํ–ˆ๋‹ค. ๋‚ด๊ฐ€ ๋งŒ๋“  ์„œ๋น„์Šค๊ฐ€ ์–ด๋–ค ๊ฒƒ์ธ์ง€๋„ ๋ชจ๋ฅด๋Š”๋ฐ ๋จผ์ € ํšŒ์›๊ฐ€์ž… ํ•˜๋ผ๋Š” ๊ฒƒ์€ ์„ค๋“๋ ฅ์ด ์ „ํ˜€ ์—†๋Š” ์ˆœ์„œ์˜€๋‹ค.

๋‘ ๋ฒˆ์งธ๋กœ๋Š” UI์ ์œผ๋กœ ๋„ˆ๋ฌด ๋น„์–ด ๋ณด์ธ๋‹ค๋Š” ์ ์ด์—ˆ๋‹ค. ์ฑ„์šฉ๊ณต๊ณ ๊ฐ€ ๋งŽ์œผ๋ฉด ๊ทธ๋‚˜๋งˆ ๊ดœ์ฐฎ์ง€๋งŒ ๋ฉ”์ธ ํŽ˜์ด์ง€๊ฐ€ ๋„ˆ๋ฌด ํœ‘ํ•ด ๋ณด์ธ๋‹ค๋Š” ์ ์ด์—ˆ๋‹ค. ์ด์ ๋„ ๊ณต๊ฐํ–ˆ๋˜ ๋ถ€๋ถ„์ด์—ˆ๋‹ค. ์ฑ„์šฉ ์„œ๋น„์Šค๋“ค์˜ ๊ฒฝ์šฐ ๋‹ค์–‘ํ•œ ์ด๋ฒคํŠธ๋“ค์„ ํ•˜๊ณ  ์žˆ์–ด์„œ ๋ฐฐ๋„ˆ๋กœ ๋ณด์—ฌ ์ฃผ์ง€๋งŒ ํ˜„์žฌ ๋‚˜๋Š” ์–ด๋–ค ๊ฑธ ๋จผ์ € ๋„์›Œ์ค˜์•ผ ํ•  ์ง€ ๊ณ ๋ฏผ์ด ๋˜๋Š” ์ƒํƒœ๋‹ค. ๋Œ€์‹ ์— ์ „์ฒด์ ์ธ UI๋ฅผ ์ข€ ๋” ๋ฐœ์ „ ์‹œ์ผœ๋ณด๋ ค๊ณ  ์ฑ„์šฉ๊ณต๊ณ  ์‚ฌ์ดํŠธ๋“ค์˜ ์˜ˆ์‹œ๋“ค์„ ์ฐธ์กฐํ–ˆ๋‹ค.

โ› ์„œ๋น„์Šค work flow ์ˆ˜์ •ํ•˜๊ธฐ

๋จผ์ € ๋ฉ”์ธ ํŽ˜์ด์ง€์—์„œ ์ฑ„์šฉ๊ณต๊ณ ๋“ค์„ ๋ณด์—ฌ์ฃผ๊ธฐ ์œ„ํ•ด์„œ๋Š” ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์™€ ์‚ฌ์šฉ์ž ๋ณ„ ๊ถŒํ•œ์„ ์ •๋ฆฌํ•  ํ•„์š”๊ฐ€ ์žˆ์—ˆ๋‹ค.

์ „์ฒด ๊ณต๊ณ ๋Š” ๋ฉ”์ธ ํŽ˜์ด์ง€์—์„œ ๋ฐ”๋กœ ๋ณผ ์ˆ˜ ์žˆ์–ด์•ผ ํ•˜๋ฏ€๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ jobs/์•ˆ์˜ ๊ฐ์ฒด๋กœ์จ ๋‹ด๊ฒจ์žˆ์œผ๋ฉด ์ข‹๊ฒ ๋‹ค๋Š” ์ƒ๊ฐ์„ ํ–ˆ๋‹ค. ๋ฐฐ์—ด๋กœ ์ •๋ฆฌํ•ด๋„ ๋˜์ง€๋งŒ ๋””ํ…Œ์ผ ํŽ˜์ด์ง€์—์„œ ์ƒ์„ธ ๋‚ด์šฉ์„ ๋ณด์—ฌ์ค˜์•ผ ํ•˜๋ฏ€๋กœ, ์ „์ฒด ๋‚ด์šฉ ์ค‘ ์›ํ•˜๋Š” ์ƒ์„ธ ๋‚ด์šฉ์„ ์ฐพ์„ ๋•Œ ๋ฐฐ์—ด๋ณด๋‹ค ๊ฐ์ฒด์—์„œ ์ฐพ๋Š” ๊ฒƒ์ด ์„ฑ๋Šฅ์ด ๋” ์ข‹๊ธฐ ๋•Œ๋ฌธ์— ๊ฐ์ฒด๋กœ ์ €์žฅํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค. ์ „์ฒด ๊ณต๊ณ ๋Š” ์„œ๋น„์Šค๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ชจ๋“  ์‚ฌ๋žŒ์ด ๋ณผ ์ˆ˜ ์žˆ์–ด์•ผ ํ•˜์ง€๋งŒ ์ˆ˜์ •, ์‚ญ์ œ, ์ถ”๊ฐ€๋Š” ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋‚˜ ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์—์„œ๋งŒ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์ƒํ–ˆ๋‹ค.

๋กœ๊ทธ์ธ๋งŒ ํ•˜๋ฉด ๋ชจ๋“  ๊ณต๊ณ ๋ฅผ ๋งˆ์Œ๋Œ€๋กœ ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์ด ๊ฑฑ์ •์ด ๋˜๊ธฐ๋„ ํ–ˆ์ง€๋งŒ, ์„œ๋น„์Šค์˜ ๋ฐฉํ–ฅ์„, ์„œ๋กœ ์ •๋ฆฌํ•œ ๊ด€์‹ฌ ์žˆ๋Š” ํšŒ์‚ฌ๋“ค์˜ ์ฑ„์šฉ๊ณต๊ณ  ๋‚ด์šฉ์„ ์ •๋ฆฌํ•˜๊ณ  ๊ณต์œ ํ•  ์ˆ˜ ์žˆ๋Š” ์„œ๋น„์Šค๋กœ ๊ตฌ์ƒํ–ˆ์œผ๋ฏ€๋กœ ์ธ์ฆ๋œ ์‚ฌ๋žŒ๋“ค์ด ๊ด€๋ฆฌ์ž ๊ถŒํ•œ๋„ ์žˆ๊ฒŒ ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์•„ ๋ณด์˜€๋‹ค.

[๊ถŒํ•œ ๋ณ„ ๊ฐ€๋Šฅํ•œ CRUD]

๊ถŒํ•œ ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž
์ „์ฒด ๊ณต๊ณ  ( jobs/) GET๋งŒ ๊ฐ€๋Šฅ GET, POST, DELETE, PUT
์œ ์ €๋ณ„ ๊ณต๊ณ  ( Users/[user]/jobs ) ๋ถˆ๊ฐ€๋Šฅ GET, POST, DELETE, PUT

์ „์ฒด๊ณต๊ณ ์™€ ์œ ์ €๋ณ„ ๊ณต๊ณ ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ API๋ฅผ ๊ตฌํ˜„ํ•˜๋ ค ํ–ˆ์ง€๋งŒ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ์—์„œ ์ฒ˜๋ฆฌํ•ด์„œ ๋ฐ›์•„์˜ค๋Š” user๋ฅผ ๋จผ์ € ๋ฐ›์•„์˜ฌ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ user์˜ ์œ ๋ฌด๋กœ ๊ฐ๊ฐ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ ๊ฐ™๋‹ค๊ณ  ์ƒ๊ฐ๋˜์—ˆ๋‹ค. ๊ทธ๋ž˜์„œ ๊ธฐ์กด DBService interface๋ฅผ ์ˆ˜์ •ํ•œ ํ›„์— react query ์ปค์Šคํ…€ ํ›…์— ๋ฐ˜์˜ํ–ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  updateJob๊ณผ addJob์˜ ํ•จ์ˆ˜๊ฐ€ ๋˜‘๊ฐ™์€ firebase์˜ set์œผ๋กœ ๋งŒ๋“ค๊ธฐ ๋•Œ๋ฌธ์— ๋‘˜์„ ํ•˜๋‚˜์˜ ํ•จ์ˆ˜๋กœ ํ•ฉ์ณค๋‹ค.

// DBType.ts

export interface DBService {
  addOrUpdateJob: (job: Job, user?: User) => Promise<void>;
  getJobs: (user?: User) => Promise<Jobs>;
  removeJob: (job: Job, user?: User) => Promise<void>;
}

//DBService.ts

  async getJobs(user?: User): Promise<Jobs> {
    const dbRef = ref(this.db);
    const query = user ? `users/${user?.id}/` : '';
    return get(child(dbRef, `${query}jobs`))
      .then((snapshot) => {
        if (snapshot.exists()) {
          return snapshot.val();
        } else {
          return {};
        }
      })
      .catch((error) => {
        console.error(error);
      });
  }

  async addOrUpdateJob(job: Job, user?: User) {
    const query = user ? `users/${user?.id}/` : '';
    return set(ref(this.db, `${query}jobs/${job.id}`), job);
  }

  async removeJob(job: Job, user?: User) {
    const query = user ? `users/${user?.id}/` : '';
    return remove(ref(this.db, `${query}jobs/${job.id}`));
  }
}
//useJobs.tsx

const JOBS_KEY = "jobs"

export const useJobs = (user?: User) => {
  const dbService = useDBService()
  const queryClient = useQueryClient()
  const { query } = useRouter()
  const { id } = query
  const jobId = typeof id === "string" ? id : id?.join() || ""

  const getJobs = useQuery([JOBS_KEY, user], async () => {
    return dbService.getJobs(user)
  })
  const addOrUpdateJob = useMutation(
    async (job: Job) => {
      return dbService.addOrUpdateJob(job, user)
    },
    {
      onSuccess: () => {
        !user && queryClient.invalidateQueries([JOBS_KEY])
        user && queryClient.invalidateQueries([JOBS_KEY, user])
      },
    }
  )

  const deleteJob = useMutation(
    async (job: Job) => {
      return dbService.removeJob(job, user)
    },
    {
      onSuccess: () => {
        !user && queryClient.invalidateQueries([JOBS_KEY])
        user && queryClient.invalidateQueries([JOBS_KEY, user])
      },
      onError: error => {
        if (error instanceof AxiosError) {
          const { response } = error
          if (response) {
            console.log(response)
          }
        }
      },
    }
  )

  const getFilteredJobs = useQuery(
    [JOBS_KEY, user],
    () => dbService.getJobs(user),
    {
      select: (data: Jobs) => {
        return Object.values(data).filter(item => item.id !== id)
      },
      onError: error => {
        console.error(error)
      },
    }
  )

  const getJobById = useQuery([JOBS_KEY, user], () => dbService.getJobs(user), {
    select: (data: Jobs) => {
      return data[jobId]
    },
    onError: error => {
      console.error(error)
    },
  })

  return { getJobs, addOrUpdateJob, deleteJob, getJobById, getFilteredJobs }
}

useJobs์—์„œ๋Š” user๊ฐ€ ์žˆ์„ ๊ฒฝ์šฐ ๋”ฐ๋กœ ๋ฐ›์•„ ์™€์•ผ ํ•˜๋ฏ€๋กœ react-query API์˜ key๊ฐ’์œผ๋กœ user๋ฅผ ํฌํ•จ ์‹œ์ผฐ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ user์˜ ์œ ๋ฌด๋กœ ์ฒ˜๋ฆฌํ•˜๋‹ค ๋ณด๋‹ˆ ๊ธฐ์กด์˜ user๊ฐ€ undefined์ผ ๋•Œ๋ฅผ ์œ„ํ•ด ๋”ฐ๋กœ ์ฒ˜๋ฆฌํ•ด์ฃผ๋˜ ๋กœ์ง์„ ์ œ์™ธํ•ด ๊น”๋”ํ•˜๊ฒŒ ๋‚˜ํƒ€๋‚ผ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

๊ถŒํ•œ์— ๋”ฐ๋ผ ์–ด๋–ป๊ฒŒ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์ฒ˜๋ฆฌํ•  ์ง€๋ฅผ ์ •ํ•˜๊ณ  ๋‚˜์„œ routing์— ๋Œ€ํ•ด์„œ๋„ ์ •๋ฆฌ๊ฐ€ ํ•„์š”ํ–ˆ๋‹ค.

๋จผ์ € ์ „์ฒด ๊ณต๊ณ ์˜ CRUD๋Š” / ์™€ /admin ๋‘ ๊ฐ€์ง€ ํŽ˜์ด์ง€๋กœ ๋‚˜๋ˆด๋‹ค. /์—์„œ๋Š” ์ „์ฒด ๊ณต๊ณ ๋ฅผ ๋จผ์ € ๋ณด์—ฌ์ฃผ๊ณ , ๋กœ๊ทธ์ธํ•œ ์œ ์ €๋Š” ๊ณต๊ณ ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๊ตฌ์ƒํ–ˆ๋‹ค. /admin์—์„œ๋Š” ์ƒˆ๋กœ์šด ๊ณต๊ณ ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๊ณ  ์ „์ฒด ๊ณต๊ณ ๋ฅผ ์ˆ˜์ •, ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋ ค ํ–ˆ๋‹ค.

์œ ์ € ๋ณ„ ๊ณต๊ณ ์˜ CRUD๋Š” ์•ž์„œ ์ •๋ฆฌํ•œ / ์—์„œ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์„ ํ•˜๊ธฐ ๋•Œ๋ฌธ์—, /user์—์„œ๋Š” ๋ชจ์€ ๊ณต๊ณ ๋ฅผ ๋ณด์—ฌ์ฃผ๊ณ  ๊ณต๊ณ ๋ฅผ ์ˆ˜์ •, ์‚ญ์ œํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ๊ตฌ์ƒํ–ˆ๋‹ค.

์ „์ฒด ๊ณต๊ณ ์™€ ์œ ์ € ๋ณ„ ๊ณต๊ณ ๋Š” ๋ชจ๋‘ ๋™์ผํ•œ JobSection ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ†ตํ•ด์„œ ๋ณด์—ฌ์ฃผ๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์— getServerSideProps๋กœ ์ „๋‹ฌ๋œ user์˜ ์œ ๋ฌด๊ฐ€ ์•„๋‹ˆ๋ผ path๊ฐ€ /user์ธ์ง€ ์•„๋‹Œ ์ง€๋ฅผ ๊ธฐ์ค€์œผ๋กœ useJobs์— user๋ฅผ ์ „๋‹ฌํ•ด์ค˜์•ผ ํ–ˆ๋‹ค.

// JobSection.tsx
export default function JobSection({
  session,
}: {
  session: Session | undefined;
}) {
  const { pathname } = useRouter();
  const isAdmin = pathname === '/admin';
  const title = getTitle(pathname);
  return (
    <Wrapper>
      <header>
        <Title>{title}</Title>
        {isAdmin && (
          <Btn href={'/admin/new'}>
            <AiOutlinePlusCircle />
          </Btn>
        )}
      </header>
      {/* <Filters /> */}
      <JobList session={session} />
    </Wrapper>
  );
}

// JobList.tsx

export default function JobList({ session }: { session: Session | undefined }) {
  const { pathname } = useRouter();
  const isUser = pathname === '/user' || pathname === '/user/[id]';
  const user = session?.user;
  const { getFilteredJobs } = useJobs(isUser ? user : undefined);
  const { isLoading, data: jobs } = getFilteredJobs;
    ...
  return (
    <Wrapper>
      {jobs && jobs.map((job) => <JobItem key={job.id} job={job} />)}
    </Wrapper>
  );
}

๋˜ํ•œ JobItem์€ ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๋Š” ์ถ”๊ฐ€, ์‚ญ์ œ ๋ฒ„ํŠผ์„ ๋ณด์—ฌ์ฃผ์ง€ ์•Š์ง€๋งŒ ๋กœ๊ทธ์ธํ•œ ์œ ์ €์˜ ๊ฒฝ์šฐ /์—์„œ๋Š” ์ถ”๊ฐ€ ๋ฒ„ํŠผ, /user์™€ /admin ์—์„œ๋Š” ์‚ญ์ œ ๋ฒ„ํŠผ์ด ์ถ”๊ฐ€ํ•ด์•ผ ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ํŽ˜์ด์ง€ ์œ„์น˜์™€ ํ•จ๊ป˜ ๋กœ๊ทธ์ธ์„ ํ–ˆ๋Š”์ง€ ์œ ๋ฌด๋ฅผ ๊ณ ๋ คํ•ด์„œ UI๋กœ ๋‚˜ํƒ€๋‚ด ์ฃผ์–ด์•ผ ํ–ˆ๋‹ค. ๋กœ๊ทธ์ธ ์œ ๋ฌด๋Š” getServersideProps๋กœ ์ „๋‹ฌ๋ฐ›์€ session์„ ์ „๋‹ฌํ•ด์ฃผ๊ธฐ ๋ณด๋‹ค useSession hook์„ ์ด์šฉํ•ด CSR์—์„œ ๋ฐ›์•„์™€ ํ™•์ธํ–ˆ๋‹ค.

  • / ์—์„œ ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž: ๋ฒ„ํŠผ์ด ๋ณด์ด์ง€ ์•Š์Œ
  • / ์—์„œ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž: ์ถ”๊ฐ€ ๋ฒ„ํŠผ์ด ๋ณด์—ฌ
  • /user์™€ /admin์—์„œ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž: ์ด๋ฏธ ๋ฆฌ๋‹ค์ด๋ ‰์…˜์œผ๋กœ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž๋Š” ํ™•์ธ์ด ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์— /ํŽ˜์ด์ง€๊ฐ€ ์•„๋‹ˆ๋ฉด ์‚ญ์ œ ๋ฒ„ํŠผ์ด ๋ณด์—ฌ
export default function JobItem({ job }: { job: Job }) {
  const { name, platform, img, checkPercentage } = job
  const { pathname, push } = useRouter()
  const isHome = pathname === "/"
  const [message, setMessage] = useState("")
  const { data: session } = useSession()
  const user = session?.user
  const isLoggedin = !!session
  const { addOrUpdateJob, deleteJob } = useJobs(user)
  const handleDelete = () => {
    deleteJob.mutate(job, {
      onSuccess: () => {
        setMessage("์„ฑ๊ณต์ ์œผ๋กœ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค")
      },
      onSettled: () => {
        setTimeout(() => setMessage(""), 4000)
      },
    })
  }
  const handleAdd = () => {
    addOrUpdateJob.mutate(job, {
      onSuccess: () => {
        setMessage("์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค")
      },
      onSettled: () => {
        setTimeout(() => setMessage(""), 4000)
      },
    })
  }

  const handleClick = () => {
    const link = redirectPath(pathname, job.id)
    push(link)
  }
  const over50Percent = checkPercentage >= 0.5

  return (
    <>
      <Wrapper>
        {over50Percent && <Badge>50% ์ด์ƒ</Badge>}
        {!isHome && (
          <Btn onClick={handleDelete}>
            <MdRemove />
          </Btn>
        )}
        {isHome && isLoggedin && (
          <Btn onClick={handleAdd}>
            <AiOutlinePlus />
          </Btn>
        )}
        <ImgBox onClick={handleClick}>
          <Img
            src={img}
            alt="job"
            sizes='(max-width: 768px) 100vw,
              (max-width: 1200px) 50vw,
              33vw"'
            fill
            priority
          />
        </ImgBox>
        <MetaBox>
          <h1>{name}</h1>
          <h3>{platform}</h3>
        </MetaBox>
      </Wrapper>
      {message && <Modal message={message} />}
    </>
  )
}

โœ’ ์ „์ฒด ๊ณต๊ณ ๋ฅผ ์ถ”๊ฐ€,์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋Š” adminForm

๊ธฐ์กด์˜ ํฌ๋กค๋ง ๋ฐฉ์‹์„ ์•„์˜ˆ ์ œ๊ฑฐํ•˜๋ฉด์„œ ์ƒˆ๋กญ๊ฒŒ ์ถ”๊ฐ€, ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ๋Š” form ํŽ˜์ด์ง€๊ฐ€ ํ•„์š”ํ–ˆ๋‹ค. form ํŽ˜์ด์ง€์—๋Š” ๊ธฐ์กด ์ฑ„์šฉ๊ณต๊ณ ์˜ ๋ฐ์ดํ„ฐ schema๋ฅผ ๋ชจ๋‘ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ–ˆ๋‹ค. schema์˜ ๋‚ด์šฉ์€ ํšŒ์‚ฌ๋ช…, URL, ์ด๋ฏธ์ง€, ํ”Œ๋žซํผ, ์ฃผ์š” ์—…๋ฌด, ์ž๊ฒฉ ์š”๊ฑด, ์šฐ๋Œ€์‚ฌํ•ญ์œผ๋กœ ์ˆ˜๋™์œผ๋กœ ์ ์–ด์ค˜์•ผ ํ–ˆ๋‹ค. ์šฐ์„ ์€ ์ผ์ผ์ด ์ ๋Š” ๋ฐฉํ–ฅ์œผ๋กœ ์ •ํ–ˆ๋‹ค. ์ดํ›„์— ์ƒˆ๋กœ์šด ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” input์—์„œ textArea๋กœ ๋ณ€๊ฒฝํ•ด ๋ณต์‚ฌ-๋ถ™์—ฌ๋„ฃ๊ธฐ๊ฐ€ ์ข€ ๋” ์‰ฝ๊ฒŒ ๋  ์ˆ˜ ์žˆ๋Š” ๋ฐฉํ–ฅ์œผ๋กœ ๊ณ ๋ฏผํ•˜๊ณ  ์žˆ๋‹ค.

์ƒˆ๋กœ์šด ๊ณต๊ณ ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ํŽ˜์ด์ง€๋Š” /admin/new๋กœ ์ˆ˜์ •ํ•  ๋•Œ๋Š” /admin/[id]๋กœ ์ˆ˜์ •์ด ๋  ์ˆ˜ ์žˆ๊ฒŒ routing์„ ๊ฒฐ์ •ํ–ˆ๋‹ค. AdminForm ์ปดํฌ๋„ŒํŠธ ๋‚ด์— ํ•จ์ˆ˜๋“ค์ด ๋งŽ์•„ ์ปค์Šคํ…€ hook์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค. message ์ƒํƒœ ๊ฐ™์€ ๊ฒฝ์šฐ mutate์™€ ํ•ญ์ƒ ๊ฐ™์ด ๋”ฐ๋ผ ๋‹ค๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์— ์—ฌ๋Ÿฌ ์ปดํฌ๋„ŒํŠธ์— ๋™์ผํ•˜๊ฒŒ ๋‚˜ํƒ€๋‚˜, ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ๋ฐ˜๋ณต์„ ์ค„์ผ ์ˆ˜ ์žˆ์„์ง€ ์ข€ ๋” ๊ณ ๋ฏผ์ด ํ•„์š”ํ•˜๋‹ค.

export default function AdminForm({ isNew, initialValue }: AdminFormProps) {
  const { job, onAdd, onChange, onDelete, onUpdateDescription } =
    useForm(initialValue)
  const [message, setMessage] = useState("")

  const DescriptionList: DescriptionListType[] = [
    {
      name: JOB_SCHEMA.MAIN_WORK,
      title: "์ฃผ์š” ์—…๋ฌด",
      value: job.mainWork,
    },
    {
      name: JOB_SCHEMA.QUALIFICATION,
      title: "์ž๊ฒฉ ์š”๊ฑด",
      value: job.qualification,
    },

    {
      name: JOB_SCHEMA.PREFERENTIAL,
      title: "์šฐ๋Œ€ ์‚ฌํ•ญ",
      value: job.preferential,
    },
  ]

  const title = isNew ? "์ƒˆ๋กœ์šด ๊ณต๊ณ  ์ถ”๊ฐ€ํ•˜๊ธฐ" : "๊ณต๊ณ  ์ˆ˜์ •ํ•˜๊ธฐ"
  const BtnText = isNew ? "์ถ”๊ฐ€ํ•˜๊ธฐ" : "์ˆ˜์ •ํ•˜๊ธฐ"

  const { addOrUpdateJob } = useJobs()
  const { mutate } = addOrUpdateJob
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const { dataset } = e.currentTarget
    if (dataset.tag !== "form") {
      return
    }
    mutate(job, {
      onSuccess: () => {
        setMessage(
          isNew ? "์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค" : "์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค"
        )
      },
      onError: error => {
        if (error instanceof AxiosError) {
          const { response } = error
          if (response) {
            setMessage(`${response.statusText} ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค`)
          }
        }
      },
      onSettled: () => {
        setTimeout(() => {
          setMessage("")
        }, 4000)
      },
    })
  }

  return (
    <Wrapper>
      ...
      <form data-tag="form" onSubmit={handleSubmit}>
        <AdminFormItem
          name={JOB_SCHEMA.NAME}
          title="ํšŒ์‚ฌ ๋ช…"
          type="text"
          value={job.name}
          onChange={onChange}
        />
        <AdminFormItem
          name={JOB_SCHEMA.URL}
          title="URL"
          type="text"
          value={job.url}
          onChange={onChange}
        />
        <AdminFormItem
          name={JOB_SCHEMA.IMG}
          title="์ด๋ฏธ์ง€"
          type="text"
          value={job.img}
          onChange={onChange}
        />
        <Select onChange={onChange} platform={job.platform} />
        {DescriptionList.map(item => (
          <AdminDescriptionList
            name={item.name}
            title={item.title}
            value={item.value}
            onAdd={onAdd}
            onDelete={onDelete}
            onChange={onUpdateDescription}
          />
        ))}
        <Btn>{BtnText}</Btn>
      </form>
      {message && <Modal message={message} />}
    </Wrapper>
  )
}

//useForm.tsx

export const useForm = (initialValue: Job) => {
  const [job, setJob] = useState<Job>(initialValue)
  const onAdd = (name: DescriptionNameType) => {
    setJob(prev => {
      const list = prev[name]
      const newItem: DescriptionType = { text: "", checked: false, id: uuid() }
      return { ...prev, [name]: [...list, newItem] }
    })
  }
  const onDelete = (name: DescriptionNameType, id: string) => {
    setJob(prev => {
      const list = prev[name].filter(item => item.id !== id)
      return { ...prev, [name]: list }
    })
  }
  const onChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => {
    const { name, value } = e.currentTarget
    setJob(prev => ({ ...prev, [name]: value }))
  }

  const onUpdateDescription = (
    name: DescriptionNameType,
    value: string,
    id: string
  ) => {
    setJob(prev => {
      const updated = prev[name].map(item => {
        if (item.id === id) {
          return { ...item, text: value }
        }
        return item
      })
      return { ...prev, [name]: updated }
    })
  }

  return { job, onAdd, onDelete, onChange, onUpdateDescription }
}

์œ„์™€ ๊ฐ™์ด ๊ธฐํš์„ ์ˆ˜์ •ํ•œ ํ›„์— ํ™ˆํŽ˜์ด์ง€๋ฅผ ๊ตฌ์„ฑํ–ˆ์„ ๋•Œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋‚˜ํƒ€๋‚ฌ๋‹ค.

[ํ™ˆํŽ˜์ด์ง€ ( /) , ์ „์ฒด ๊ณต๊ณ ์˜ ์ƒ์„ธ ํŽ˜์ด์ง€ (/jobs/:id) ]

ํ™ˆํŽ˜์ด์ง€, ์ „์ฒด๊ณต๊ณ  ์ƒ์„ธ ํŽ˜์ด์ง€
ํ™ˆํŽ˜์ด์ง€, ์ „์ฒด๊ณต๊ณ  ์ƒ์„ธ ํŽ˜์ด์ง€

[์œ ์ € ๋ณ„ ํŽ˜์ด์ง€ ( /user ), ์œ ์ €๋ณ„ ์ƒ์„ธ ํŽ˜์ด์ง€ (/user/:id) ]

์œ ์ €๋ณ„ ํŽ˜์ด์ง€, ์œ ์ €๋ณ„ ์ƒ์„ธ ํŽ˜์ด์ง€
์œ ์ €๋ณ„ ํŽ˜์ด์ง€, ์œ ์ €๋ณ„ ์ƒ์„ธ ํŽ˜์ด์ง€

[admin ํŽ˜์ด์ง€ ( /admin ), admin ์ƒ์„ธ ํŽ˜์ด์ง€ (/admin/:id) ]

admin
admin

๐ŸŽจ ๋””์ž์ธ ์ˆ˜์ •

๋””์ž์ธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›๊ณ  ์ฒ˜์Œ์—๋Š” ์ž˜ ํ•ด๋’€๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ์—ˆ๋Š”๋ฐ ๋น„์–ด ๋ณด์ธ๋‹ค๋Š” ์ƒ๊ฐ์ด ๋งŽ์ด ๋“ค์—ˆ๋‹ค. ๋จผ์ € ์ƒ๊ฐ์ด ๋“  ๋ถ€๋ถ„์€ ๋ฐฐ๊ฒฝ๊ณผ ์ปจํ…์ธ ์˜ ์ƒ‰์ด ๋˜‘๊ฐ™๊ธฐ ๋•Œ๋ฌธ์— ๋น„์–ด ๋ณด์ด๋Š” ๊ฒŒ ํฌ๋‹ค๋Š” ์ƒ๊ฐ์ด ๋“ค์–ด์„œ ์ปจํ…์ธ ์™€ ๋ฐฐ๊ฒฝ ์ƒ‰์„ ๊ตฌ๋ถ„ํ•˜๊ณ  ๊ธฐ์กด ๋ฉ”์ธ ํŽ˜์ด์ง€๋ถ€ํ„ฐ ์ˆœ์„œ๋Œ€๋กœ ์†๋ณด๊ธฐ ์‹œ์ž‘ํ–ˆ๋‹ค.

๋ฉ”์ธ ํŽ˜์ด์ง€

๊ธฐ์กด์˜ ๋ฉ”์ธ ํŽ˜์ด์ง€๋Š” ํฌ๋กค๋ง์„ ํ•  ์ˆ˜ ์žˆ๋Š” form์ด ์žˆ์–ด์„œ ์ปจํ…์ธ ๊ฐ€ ๋งŽ์ด ์—†์–ด๋„ ๊ดœ์ฐฎ์•˜์ง€๋งŒ form์ด ์‚ฌ๋ผ์ง€๊ณ  ๋‚œ ๋’ค์—๋Š” ํ—ˆ์ „ํ•จ์ด ๋” ์ปค ๋ณด์˜€๋‹ค. ๊ทธ๋ž˜์„œ ์šฐ์„ ์€ ํ•„์š” ์—†๋Š” ๋ฐฐ๋„ˆ๋Š” ์—†์• ๊ณ  ๊ฐ jobItem์˜ ํฌ๊ธฐ๋ฅผ ํ‚ค์›Œ์„œ ๋ณด๋‹ค ๊ณต๊ณ ๊ฐ€ ์ž˜ ๋ณด์ด๊ฒŒ ์ˆ˜์ •ํ–ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ํ™ˆํŽ˜์ด์ง€ ๋””์ž์ธ์„ ์ถ”๊ฐ€ํ•œ๋‹ค๋ฉด ๋ชจ๋“  ๊ณต๊ณ ๋กœ ํŽ˜์ด์ง€๋ฅผ ๋ถ„๋ฆฌํ•˜๊ณ  ๋ฉ”์ธ ํŽ˜์ด์ง€์—์„œ ์‚ฌ์šฉ๋ฒ•์— ๋Œ€ํ•ด์„œ ๋‚˜ํƒ€๋‚ด ์ฃผ๋ฉด ์ข€ ๋” ์ข‹์ง€ ์•Š์„๊นŒ ์ƒ๊ฐ๋„ ๋“ค์—ˆ๋‹ค. ๊ณต๊ณ ๊ฐ€ ๋งŽ์•„์ง€๋ฉด ํŽ˜์ด์ง€๋„ค์ด์…˜์ด๋‚˜ ๋ฌดํ•œ ์Šคํฌ๋กค์„ ์ถ”๊ฐ€ํ•ด์„œ ๊ธฐ๋Šฅ์ ์ธ ๋ณด์ถฉ๋„ ํ•„์š”ํ•  ๊ฒƒ ๊ฐ™๋‹ค.

[๋ฉ”์ธ ํŽ˜์ด์ง€]

๋ฉ”์ธํŽ˜์ด์ง€
๋ฉ”์ธํŽ˜์ด์ง€

์ƒ์„ธ ํŽ˜์ด์ง€

๊ธฐ์กด ์ƒ์„ธ ํŽ˜์ด์ง€์˜ ๋ฌธ์ œ์ ์€ ์‚ฌ์ง„๊ณผ ์ฑ„์šฉ๊ณต๊ณ ์˜ ์ฃผ์š”์—…๋ฌด๋ฅผ ํ•œ ์ค„์— ๋‚˜ํƒ€๋‚ด๋‹ค ๋ณด๋‹ˆ ์‚ฌ์ง„์˜ ํฌ๊ธฐ๋‚˜ ์ฃผ์š” ์—…๋ฌด์˜ ์–‘์— ๋”ฐ๋ผ ๋ ˆ์ด์•„์›ƒ์— ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒผ๋‹ค. ์ด๊ฒƒ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ ์šฐ์„ ์€ ๋ชจ๋‘ ์„ธ๋กœ๋กœ ๋ ˆ์ด์•„์›ƒ์„ ๋ณ€๊ฒฝํ–ˆ๊ณ , ์ด๋ฏธ์ง€ ํฌ๊ธฐ๋„ ๊ณ ์ • ์‹œ์ผœ๋‘์—ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ๋‚˜ํƒ€๋‚ด๋ฉด ๊ฐ€๋กœ๋กœ ๋„ˆ๋ฌด ๋น„๊ฒŒ ๋˜๋Š” ๊ฒŒ ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒผ๋Š”๋ฐ, ์ด์ ์€ ํ”„๋กœ๊ทธ๋ž˜๋จธ์Šค์™€ ์›ํ‹ฐ๋“œ ํŽ˜์ด์ง€๋ฅผ ์ฐธ๊ณ ํ•ด Sidebox ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ–ˆ๋‹ค. Sidebox๋Š” sticky๋ฅผ ์ด์šฉํ•ด ์‚ฌ์šฉ์ž์˜ ์Šคํฌ๋กค ์œ„์น˜์™€ ์ƒ๊ด€์—†์ด ๊ณ„์†ํ•ด์„œ ๋ณด์—ฌ ์ฃผ๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™์€ ํšŒ์‚ฌ์ด๋ฆ„๊ณผ ์ถ”๊ฐ€ ๋ฒ„ํŠผ์„ ๋‹ด์•˜๋‹ค.

[ํ”„๋กœ๊ทธ๋ž˜๋จธ์Šค ์ฑ„์šฉ๊ณต๊ณ  ํŽ˜์ด์ง€]

ํ”„๋กœ๊ทธ๋ž˜๋จธ์Šค
ํ”„๋กœ๊ทธ๋ž˜๋จธ์Šค

[์ˆ˜์ •ํ•œ ์ƒ์„ธ ํŽ˜์ด์ง€ ] ์ˆ˜์ •ํ•œ ์ƒ์„ธํŽ˜์ด์ง€

AdminForm

AdminForm์œผ๋กœ ๋ช‡ ๊ฐœ์˜ ์ฑ„์šฉ๊ณต๊ณ ๋ฅผ ์ง์ ‘ ์˜ฌ๋ฆฌ๋ฉด์„œ ํ˜„์žฌ ๋””์ž์ธ์€ ๊ณต๊ณ ์˜ ๋‚ด์šฉ์„ ๋‹ด๊ธฐ์— ๊ฐ€๋…์„ฑ๋„ ๋–จ์–ด์ง€๊ณ  ์ผ์ผ์ด ์˜ฎ๊ฒจ์•ผ ํ•˜๋Š” ๋ถˆํŽธํ•จ๋„ ์žˆ์—ˆ๋‹ค. ์ด์ ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„œ ๋””์ž์ธ์„ ์ƒ์„ธ ํŽ˜์ด์ง€ ๋•Œ์™€ ๊ฐ™์ด ์„ธ๋กœ๋กœ ๋ ˆ์ด์•„์›ƒ์„ ๋ฐ”๊พธ๊ณ , ํ•ญ๋ชฉ ํ•˜๋‚˜ํ•˜๋‚˜๋ฅผ ๋‹ด๋˜ <input/>์ด ์•„๋‹ˆ๋ผ ํ†ต์œผ๋กœ ๋ณต์‚ฌ, ๋ถ™์—ฌ๋„ฃ๊ธฐ ํ•  ์ˆ˜ ์žˆ๊ฒŒ <textarea/>๋กœ ํƒœ๊ทธ๋ฅผ ๋ฐ”๊ฟ”์„œ ํŽธ์˜์„ฑ์„ ๋†’์˜€๋‹ค. ๊ธฐ์กด input์œผ๋กœ ํƒ€์ž…๊ณผ ๋ชจ๋“  ํ•จ์ˆ˜๋ฅผ ์งœ๋†จ๊ธฐ ๋•Œ๋ฌธ์— ์ƒํƒœ๋ฅผ ๋”ฐ๋กœ ์ถ”๊ฐ€ํ•ด์„œ ์ดํ›„์— submitํ•  ๋•Œ normalize์‹œํ‚ค๋Š” ๋ฐฉํ–ฅ์œผ๋กœ ์ž‘์—…์„ ์ง„ํ–‰ํ–ˆ๋‹ค.

value๊ฐ’์ด textArea๋Š” string์ด๊ณ  ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋Š” ๋ฐฐ์—ด์ด์–ด์„œ type์„ ์ด์šฉํ•ด ๋กœ์ง์„ ๊ตฌ๋ถ„ ์‹œํ‚ฌ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

// AdminDescriptionList.tsx
export default function AdminDescriptionList({
  name,
  title,
  value,
  onAdd,
  onDelete,
  onChange,
  onNewDescriptionChange,
}: AdminDescriptionListProps) {
  const isString = typeof value === "string"
  return (
    <Wrapper>
      ...
      {isString && (
        <TextArea
          text={value}
          name={name}
          onChange={onNewDescriptionChange}
        ></TextArea>
      )}
      {!isString && (
        <ul>
          {value.map(item => (
            <AdminDescriptionItem
              key={item.id}
              name={name}
              item={item}
              onChange={onChange}
              onDelete={onDelete}
            />
          ))}
        </ul>
      )}
    </Wrapper>
  )
}

// TextArea.tsx
export default function TextArea({ name, text, onChange }: TextAreaType) {
  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const { value } = e.currentTarget
    onChange(name, value)
  }
  return <Wrapper required value={text} onChange={handleChange}></Wrapper>
}

ํฌ๋กค๋ง์œผ๋กœ ๋ฌธ์ž์—ด์„ ์ฒ˜๋ฆฌํ•  ๋•Œ๋Š” ๋„ˆ๋ฌด ๋งŽ์€ ์ œ์•ฝ์ด ์žˆ์—ˆ์ง€๋งŒ ์ง์ ‘ ๋ณต์‚ฌ, ๋ถ™์—ฌ ๋„ฃ๊ธฐ๋ฅผ ํ•œ๋‹ค๊ณ  ํ–ˆ์„ ๋•Œ๋Š” ํ•ด๋‹น ๋ถ€๋ถ„์˜ ๋‚ด์šฉ๋งŒ ๊ฐ€์ ธ์˜ค๋ฉด ๋˜์„œ, โ€ข ์™€ -๋ฅผ ์ œ๊ฑฐํ•ด์ฃผ๊ณ  ๋„์–ด์“ฐ๊ธฐ๋กœ split๋งŒ ํ•˜๋ฉด ๊ฐ„๋‹จํ•˜๊ฒŒ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€๊ณตํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ๊ฐ€๊ณต๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ์กด handleSubmit๊ณผ ์—ฐ๊ฒฐํ•  ๋•Œ, normalizeํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ setJob์œผ๋กœ ์—…๋ฐ์ดํŠธ ์‹œํ‚ค๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ ๊ทธ๋Œ€๋กœ ๊ฐ’์„ ์‚ฌ์šฉํ•ด์„œ API๋ฅผ ํ˜ธ์ถœํ–ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ์ฒ˜๋ฆฌํ•œ ์ด์œ ๋Š” setState๋กœ ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๊ณ  mutate๋ฅผ ํ•˜๋ฉด ๋™๊ธฐ์  ์ฝ”๋“œ์ง€๋งŒ setState๊ฐ€ ๋น„๋™๊ธฐ์ ์œผ๋กœ ์ฒ˜๋ฆฌ๋  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ด์—ˆ๋‹ค.

// normalizeDescription.ts

type RawDescriptionsType = {
  mainWork: string;
  qualification: string;
  preferential: string;
};

type NormalizedDescriptionsType = {
  mainWork: DescriptionType[];
  qualification: DescriptionType[];
  preferential: DescriptionType[];
  [index: string]: DescriptionType[];
};

export const normalizeDescriptions = (
  descriptions: RawDescriptionsType
): NormalizedDescriptionsType => {
  const result: NormalizedDescriptionsType = {
    mainWork: [],
    qualification: [],
    preferential: [],
  };
  const reg = /[โ€ข-]/gi;
  for (const [key, value] of Object.entries(descriptions)) {
    const text = value.replace(reg, '').trim();
    const items = text.split('\n');
    const normalizedItems = items.map((item) => {
      return { id: uuid(), text: item, checked: false };
    });
    result[key] = normalizedItems;
  }
  return result;
};


// AdminForm.tsx

 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const { dataset } = e.currentTarget;
    if (dataset.tag !== 'form') {
      return;
    }
    let targetJob = job;

    if (isNew) {
      const normalizedDescriptions = normalizeDescriptions(descriptions);
      targetJob = { ...job, ...normalizedDescriptions };
    }
    mutate(targetJob, {...})
  };

[์ˆ˜์ •ํ•œ ์ „์ฒด๊ณต๊ณ  ์ถ”๊ฐ€ ํŽ˜์ด์ง€ (/admin/new)] ์ˆ˜์ •ํ•œ ์ „์ฒด๊ณต๊ณ  ์ถ”๊ฐ€ ํŽ˜์ด์ง€

๋งˆ์น˜๋ฉฐ

์•„์ง ํ•˜๊ณ  ์‹ถ์€ ๊ฒƒ๋„ ๋ถ€์กฑํ•œ ๊ฒƒ๋„ ๋งŽ์€ ํ”„๋กœ์ ํŠธ๋ผ ๋งค๋ฒˆ ์ƒˆ๋กœ์šด ์‹œ๋„๋“ค์„ ํ•  ๋•Œ ์ฆ๊ฒ๋‹ค. ๋ฌผ๋ก  ํ˜„์‹ค์€ ์ด๋ ฅ์„œ๋ฅผ ์“ฐ๊ณ  ๋–จ์–ด์ง€๋Š” ๋‚ ๋“ค์˜ ์—ฐ์†์ด์ง€๋งŒ ๋ฌด์กฐ๊ฑด ๊ฐœ๋ฐœ์ž๊ฐ€ ๋œ๋‹ค๋Š” ์ƒ๊ฐ์œผ๋กœ ์ง€๊ธˆ ๋‚ด๊ฐ€ ์žˆ๋Š” ์ž๋ฆฌ์—์„œ ๋” ์ž˜ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•๋“ค์„ ๋ฐ˜์˜ํ•˜๋‹ค ๋ณด๋ฉด ์ •๋ง ์›ํ•˜๋Š” ํšŒ์‚ฌ์—์„œ ๋‚ด๊ฐ€ ์›ํ•˜๋Š” ์„œ๋น„์Šค๋ฅผ ๋งŒ๋“ค๊ณ  ์žˆ์ง€ ์•Š์„๊นŒ. ๋‚ด์ผ ๋‚˜๋ฅผ ํ•œ๋ฒˆ ๋” ๋ฏฟ์–ด๋ณด๊ฒ ๋‹ค๋Š” ๋ง˜์œผ๋กœ ๊ฐœ๋ฐœ์„ ์ฆ๊ธฐ๋ฉฐ ์ด ์‹œ๊ฐ„์„ ๋ฒ„ํ…จ๋‚˜๊ฐ€๋ ค ํ•œ๋‹ค.

@choi2021
๋งค์ผ์˜ ์‹œํ–‰์ฐฉ์˜ค๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฐœ๋ฐœ์ผ์ง€์ž…๋‹ˆ๋‹ค.